@tnotesjs/ui 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -29
- package/dist/code/highlight.d.ts +42 -0
- package/dist/code/highlight.js +137 -0
- package/dist/components/Footprints/parse.d.ts +17 -0
- package/dist/components/Footprints/parse.js +105 -0
- package/dist/components/Mindmap/markdown.d.ts +20 -0
- package/dist/components/Mindmap/markdown.js +60 -0
- package/dist/markdown/image.d.ts +33 -0
- package/dist/markdown/image.js +83 -0
- package/package.json +49 -7
- package/src/browser/clipboard.ts +32 -0
- package/src/browser/theme.ts +22 -0
- package/src/code/highlight.test.ts +55 -0
- package/src/code/highlight.ts +170 -0
- package/src/components/Badge/Badge.vue +43 -0
- package/src/components/CodeBlock/CodeBlock.vue +172 -0
- package/src/components/CodeGroup/CodeGroup.vue +96 -0
- package/src/components/Footprints/Footprints.vue +1 -1
- package/src/components/ImagePreview/ImagePreview.vue +315 -0
- package/src/components/Mermaid/Mermaid.test.ts +54 -0
- package/src/components/Mermaid/Mermaid.vue +229 -56
- package/src/components/Mermaid/adoptLiveSvg.test.ts +16 -0
- package/src/components/Mermaid/adoptLiveSvg.ts +15 -0
- package/src/components/Mindmap/Mindmap.vue +2 -0
- package/src/components/sharedMedia.test.ts +139 -0
- package/src/entries/bilibili-video.ts +1 -0
- package/src/entries/footprints.ts +1 -0
- package/src/entries/mermaid.ts +1 -0
- package/src/entries/mindmap.ts +1 -0
- package/src/entries/notes-table.ts +2 -0
- package/src/entries/word-list.ts +1 -0
- package/src/icons/check.svg +3 -0
- package/src/icons/copy.svg +4 -0
- package/src/index.ts +31 -18
- package/src/markdown/image.test.ts +88 -0
- package/src/markdown/image.ts +115 -0
- package/src/styles/code.css +184 -0
- package/src/styles/prose.css +211 -0
- package/src/styles/swiper.css +151 -0
- package/src/styles/tokens.css +54 -18
- package/src/swiper/hydrate.test.ts +65 -0
- package/src/swiper/hydrate.ts +164 -0
package/README.md
CHANGED
|
@@ -40,13 +40,36 @@ Published packages should depend on the npm version of `@tnotesjs/ui`, not `file
|
|
|
40
40
|
|
|
41
41
|
## Components
|
|
42
42
|
|
|
43
|
+
### Shared rendering contract
|
|
44
|
+
|
|
45
|
+
Core and Desk import the same token, prose, code-block, and code-group implementation. The
|
|
46
|
+
`SHARED_CODE_GROUP_CONTRACT` fixture is exercised in both repositories' CI suites so changes to
|
|
47
|
+
fence metadata, line numbering, titles, or generated markup cannot drift silently.
|
|
48
|
+
|
|
49
|
+
| Syntax / capability | Core page | Desk visual edit | Desk read-only | Desk source |
|
|
50
|
+
| --------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------- | --------------------- | ------------------ |
|
|
51
|
+
| Markdown prose (headings, links, lists, tables, quotes) | Shared `prose.css` | Shared typography + editor hit areas | Shared `prose.css` | Canonical Markdown |
|
|
52
|
+
| Fenced code + lazy Shiki language loading | Shared | Shared in code-group previews; CodeMirror while editing | Shared | Byte-preserved |
|
|
53
|
+
| `::: code-group` | Shared `CodeGroup` | Shared atomic preview | Shared | Byte-preserved |
|
|
54
|
+
| `:line-numbers`, `:line-numbers=N`, `:no-line-numbers` | Yes | Yes | Yes | Byte-preserved |
|
|
55
|
+
| `{N}` fence line highlights | Yes | Yes (CodeMirror) | Yes | Byte-preserved |
|
|
56
|
+
| Shiki `[!code]` annotations | No (aligned with Desk) | No | No | Shown as source |
|
|
57
|
+
| Inline `<Badge>` | Shared `Badge` | Shared inline projection | Shared | Byte-preserved |
|
|
58
|
+
| `NotesTable` | Shared component via Core data adapter | Shared component projection | Shared | Byte-preserved |
|
|
59
|
+
| Math | TNotes SSG MathJax pipeline | Milkdown/KaTeX | Milkdown/KaTeX | Byte-preserved |
|
|
60
|
+
| Image lightbox, navigation, zoom, and pan | Shared `ImagePreview` | Shared `ImagePreview` | Shared `ImagePreview` | N/A |
|
|
61
|
+
| Mermaid / Mindmap / Footprints / BilibiliVideo / WordList | Shared | Shared projection | Shared | Byte-preserved |
|
|
62
|
+
|
|
63
|
+
The hosts may add editing handles, navigation offsets, or knowledge-base data adapters. They must
|
|
64
|
+
not redefine the shared color tokens, prose typography, or code rendering shell.
|
|
65
|
+
|
|
43
66
|
### `BilibiliVideo` — `<BilibiliVideo id="BVxxxx" />`
|
|
44
67
|
|
|
45
|
-
| Prop
|
|
46
|
-
|
|
47
|
-
| `id`
|
|
48
|
-
| `autoplay` | `false`
|
|
49
|
-
| `muted`
|
|
68
|
+
| Prop | Default | Notes |
|
|
69
|
+
| ---------- | -------- | ------------------------------------------- |
|
|
70
|
+
| `id` | required | BV id |
|
|
71
|
+
| `autoplay` | `false` | Embed URL sends `autoplay=0` unless enabled |
|
|
72
|
+
| `muted` | `false` | Embed URL sends `muted=0\|1` |
|
|
50
73
|
|
|
51
74
|
```md
|
|
52
75
|
<BilibiliVideo id="BV1QR4y1y7GG" :autoplay="true" :muted="true" />
|
|
@@ -54,13 +77,13 @@ Published packages should depend on the npm version of `@tnotesjs/ui`, not `file
|
|
|
54
77
|
|
|
55
78
|
### `WordList` — `<WordList :words="[…]" />`
|
|
56
79
|
|
|
57
|
-
| Prop
|
|
58
|
-
|
|
59
|
-
| `words`
|
|
60
|
-
| `needSort`
|
|
61
|
-
| `wordsBaseUrl`
|
|
62
|
-
| `wordsRawBaseUrl` |
|
|
63
|
-
| `features`
|
|
80
|
+
| Prop | Default | Notes |
|
|
81
|
+
| ----------------- | ------------------------- | ---------------------------- |
|
|
82
|
+
| `words` | `[]` | Word strings |
|
|
83
|
+
| `needSort` | `false` | Sort A→Z by first letter |
|
|
84
|
+
| `wordsBaseUrl` | en-words blob URL | Optional host override |
|
|
85
|
+
| `wordsRawBaseUrl` | en-words raw URL | Optional host override |
|
|
86
|
+
| `features` | `WORD_LIST_FEATURES_FULL` | Capability flags (see below) |
|
|
64
87
|
|
|
65
88
|
```md
|
|
66
89
|
<WordList :words="['cancel', 'salary']" :needSort="true" />
|
|
@@ -68,30 +91,30 @@ Published packages should depend on the npm version of `@tnotesjs/ui`, not `file
|
|
|
68
91
|
|
|
69
92
|
**Features presets** (no consumer/`host` field — capability-based):
|
|
70
93
|
|
|
71
|
-
| Preset
|
|
72
|
-
|
|
73
|
-
| `WORD_LIST_FEATURES_FULL` (default) | ✓
|
|
74
|
-
| `WORD_LIST_FEATURES_STATIC`
|
|
94
|
+
| Preset | Cards | Fetch word data | Menu Pin | Menu Auto Show Card |
|
|
95
|
+
| ----------------------------------- | ----- | --------------- | -------- | ------------------- |
|
|
96
|
+
| `WORD_LIST_FEATURES_FULL` (default) | ✓ | ✓ | ✓ | ✓ |
|
|
97
|
+
| `WORD_LIST_FEATURES_STATIC` | — | — | — | — |
|
|
75
98
|
|
|
76
99
|
### `Mermaid`
|
|
77
100
|
|
|
78
101
|
Shared diagram preview for core (`tn:dev`) and Desk. Markdown fence language: `mermaid`.
|
|
79
102
|
|
|
80
|
-
| Prop
|
|
81
|
-
|
|
82
|
-
| `source`
|
|
83
|
-
| `graph`
|
|
84
|
-
| `id`
|
|
85
|
-
| `center`
|
|
86
|
-
| `isDark`
|
|
87
|
-
| `securityLevel`
|
|
88
|
-
| `enableCopy`
|
|
89
|
-
| `enableFullscreen`
|
|
90
|
-
| `enableCenterToggle` | `true`
|
|
103
|
+
| Prop | Default | Notes |
|
|
104
|
+
| -------------------- | --------- | ------------------------------------------------ |
|
|
105
|
+
| `source` | `''` | Plain Mermaid text (Desk) |
|
|
106
|
+
| `graph` | `''` | URI-encoded source (VitePress fence) |
|
|
107
|
+
| `id` | auto | Mermaid render id |
|
|
108
|
+
| `center` | `false` | From fence keyword `center`; omit → not centered |
|
|
109
|
+
| `isDark` | auto | Or pass explicitly |
|
|
110
|
+
| `securityLevel` | `'loose'` | Same default for Desk and site preview |
|
|
111
|
+
| `enableCopy` | `true` | Hover copy action |
|
|
112
|
+
| `enableFullscreen` | `true` | Hover fullscreen action |
|
|
113
|
+
| `enableCenterToggle` | `true` | Hover center toggle (left of fullscreen) |
|
|
91
114
|
|
|
92
115
|
Fence:
|
|
93
116
|
|
|
94
|
-
-
|
|
95
|
-
-
|
|
117
|
+
- ` ```mermaid ` → not centered
|
|
118
|
+
- ` ```mermaid center ` → centered
|
|
96
119
|
|
|
97
120
|
Icons for the toggle live in `src/components/Mermaid/icons/` (`icon__center_on.svg` / `icon__center_off.svg`).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { HighlighterCore } from 'shiki/core';
|
|
2
|
+
|
|
3
|
+
interface CodeMeta {
|
|
4
|
+
language: string;
|
|
5
|
+
title: string;
|
|
6
|
+
lineNumbers: boolean;
|
|
7
|
+
startLine: number;
|
|
8
|
+
highlightedLines: number[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A single cross-repository fixture. Core and Desk both consume this contract in
|
|
12
|
+
* their own test suites, so a parser or DOM change cannot silently diverge.
|
|
13
|
+
*/
|
|
14
|
+
declare const SHARED_CODE_GROUP_CONTRACT: {
|
|
15
|
+
readonly source: string;
|
|
16
|
+
readonly items: readonly [{
|
|
17
|
+
readonly info: "js:line-numbers=4 {2} [one.js]";
|
|
18
|
+
readonly code: "console.log(1)\nconsole.log(2)";
|
|
19
|
+
readonly title: "one.js";
|
|
20
|
+
readonly language: "js";
|
|
21
|
+
readonly lineNumbers: true;
|
|
22
|
+
readonly startLine: 4;
|
|
23
|
+
readonly highlightedLines: readonly [2];
|
|
24
|
+
}, {
|
|
25
|
+
readonly info: "ts:no-line-numbers [two.ts]";
|
|
26
|
+
readonly code: "const value: number = 2";
|
|
27
|
+
readonly title: "two.ts";
|
|
28
|
+
readonly language: "ts";
|
|
29
|
+
readonly lineNumbers: false;
|
|
30
|
+
readonly startLine: 1;
|
|
31
|
+
readonly highlightedLines: readonly [];
|
|
32
|
+
}];
|
|
33
|
+
};
|
|
34
|
+
declare function parseCodeMeta(info?: string, defaultLineNumbers?: boolean): CodeMeta;
|
|
35
|
+
declare function normalizeCodeLanguage(language: string): string;
|
|
36
|
+
/** One engine and one grammar cache for build-time and browser consumers. */
|
|
37
|
+
declare function prepareCodeHighlighter(languages?: string[]): Promise<HighlighterCore>;
|
|
38
|
+
/** Markdown renderers prewarm document languages before their synchronous render pass. */
|
|
39
|
+
declare function highlightCodeSync(code: string, info?: string): string;
|
|
40
|
+
declare function highlightCode(code: string, info?: string): Promise<string>;
|
|
41
|
+
|
|
42
|
+
export { type CodeMeta, SHARED_CODE_GROUP_CONTRACT, highlightCode, highlightCodeSync, normalizeCodeLanguage, parseCodeMeta, prepareCodeHighlighter };
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// src/code/highlight.ts
|
|
2
|
+
import {
|
|
3
|
+
createHighlighterCore
|
|
4
|
+
} from "shiki/core";
|
|
5
|
+
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
|
|
6
|
+
import { bundledLanguages, bundledLanguagesAlias } from "shiki/langs";
|
|
7
|
+
var SHARED_CODE_GROUP_CONTRACT = {
|
|
8
|
+
source: [
|
|
9
|
+
"::: code-group",
|
|
10
|
+
"```js:line-numbers=4 {2} [one.js]",
|
|
11
|
+
"console.log(1)",
|
|
12
|
+
"console.log(2)",
|
|
13
|
+
"```",
|
|
14
|
+
"```ts:no-line-numbers [two.ts]",
|
|
15
|
+
"const value: number = 2",
|
|
16
|
+
"```",
|
|
17
|
+
":::"
|
|
18
|
+
].join("\n"),
|
|
19
|
+
items: [
|
|
20
|
+
{
|
|
21
|
+
info: "js:line-numbers=4 {2} [one.js]",
|
|
22
|
+
code: "console.log(1)\nconsole.log(2)",
|
|
23
|
+
title: "one.js",
|
|
24
|
+
language: "js",
|
|
25
|
+
lineNumbers: true,
|
|
26
|
+
startLine: 4,
|
|
27
|
+
highlightedLines: [2]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
info: "ts:no-line-numbers [two.ts]",
|
|
31
|
+
code: "const value: number = 2",
|
|
32
|
+
title: "two.ts",
|
|
33
|
+
language: "ts",
|
|
34
|
+
lineNumbers: false,
|
|
35
|
+
startLine: 1,
|
|
36
|
+
highlightedLines: []
|
|
37
|
+
}
|
|
38
|
+
]
|
|
39
|
+
};
|
|
40
|
+
function parseCodeMeta(info = "", defaultLineNumbers = true) {
|
|
41
|
+
const language = info.trim().match(/^[^\s{\[]+/)?.[0]?.split(":")[0] || "text";
|
|
42
|
+
const highlightedLines = /* @__PURE__ */ new Set();
|
|
43
|
+
const ranges = info.match(/\{([\d,\s-]+)\}/)?.[1] || "";
|
|
44
|
+
for (const range of ranges.split(",")) {
|
|
45
|
+
const match = range.trim().match(/^(\d+)(?:-(\d+))?$/);
|
|
46
|
+
if (!match) continue;
|
|
47
|
+
const start = Number(match[1]);
|
|
48
|
+
const end = Number(match[2] ?? start);
|
|
49
|
+
if (start < 1 || end < start || end - start > 1e4) continue;
|
|
50
|
+
for (let line = start; line <= end; line++) highlightedLines.add(line);
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
language,
|
|
54
|
+
title: info.match(/\[([^\]]*)\]/)?.[1]?.trim() || "",
|
|
55
|
+
lineNumbers: /:no-line-numbers\b/.test(info) ? false : /:line-numbers\b/.test(info) || defaultLineNumbers,
|
|
56
|
+
startLine: Math.max(1, Number(info.match(/:line-numbers=(\d+)/)?.[1] || 1)),
|
|
57
|
+
highlightedLines: [...highlightedLines].sort((a, b) => a - b)
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
var singleton;
|
|
61
|
+
var ready;
|
|
62
|
+
var loading = /* @__PURE__ */ new Map();
|
|
63
|
+
function normalizeCodeLanguage(language) {
|
|
64
|
+
const id = language.toLowerCase();
|
|
65
|
+
if (id === "plain" || id === "txt" || id === "plaintext" || !id)
|
|
66
|
+
return "text";
|
|
67
|
+
if (id in bundledLanguages || id in bundledLanguagesAlias) return id;
|
|
68
|
+
return "text";
|
|
69
|
+
}
|
|
70
|
+
async function prepareCodeHighlighter(languages = []) {
|
|
71
|
+
singleton ??= createHighlighterCore({
|
|
72
|
+
engine: createJavaScriptRegexEngine(),
|
|
73
|
+
themes: [
|
|
74
|
+
import("shiki/themes/github-light.mjs"),
|
|
75
|
+
import("shiki/themes/github-dark.mjs")
|
|
76
|
+
],
|
|
77
|
+
langs: []
|
|
78
|
+
}).then((highlighter2) => ready = highlighter2);
|
|
79
|
+
const highlighter = await singleton;
|
|
80
|
+
await Promise.all(
|
|
81
|
+
languages.map(async (requested) => {
|
|
82
|
+
const language = normalizeCodeLanguage(requested);
|
|
83
|
+
if (language === "text" || highlighter.getLoadedLanguages().includes(language))
|
|
84
|
+
return;
|
|
85
|
+
let pending = loading.get(language);
|
|
86
|
+
if (!pending) {
|
|
87
|
+
const loader = bundledLanguages[language] ?? bundledLanguagesAlias[language];
|
|
88
|
+
pending = highlighter.loadLanguage(loader).then(() => void 0);
|
|
89
|
+
loading.set(language, pending);
|
|
90
|
+
pending.catch(() => loading.delete(language));
|
|
91
|
+
}
|
|
92
|
+
await pending;
|
|
93
|
+
})
|
|
94
|
+
);
|
|
95
|
+
return highlighter;
|
|
96
|
+
}
|
|
97
|
+
function transformers(meta) {
|
|
98
|
+
return [
|
|
99
|
+
{
|
|
100
|
+
name: "tnotes-code-meta",
|
|
101
|
+
pre(node) {
|
|
102
|
+
this.addClassToHast(node, "tn-code-highlight");
|
|
103
|
+
node.properties.tabindex = 0;
|
|
104
|
+
},
|
|
105
|
+
line(node, line) {
|
|
106
|
+
node.properties["data-line"] = String(meta.startLine + line - 1);
|
|
107
|
+
if (meta.highlightedLines.includes(line))
|
|
108
|
+
this.addClassToHast(node, "highlighted");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
function highlightCodeSync(code, info = "") {
|
|
114
|
+
if (!ready)
|
|
115
|
+
throw new Error("Call prepareCodeHighlighter before rendering Markdown");
|
|
116
|
+
const meta = parseCodeMeta(info);
|
|
117
|
+
const normalizedLanguage = normalizeCodeLanguage(meta.language);
|
|
118
|
+
const language = ready.getLoadedLanguages().includes(normalizedLanguage) ? normalizedLanguage : "text";
|
|
119
|
+
return ready.codeToHtml(code.replace(/\n$/, ""), {
|
|
120
|
+
lang: language,
|
|
121
|
+
themes: { light: "github-light", dark: "github-dark" },
|
|
122
|
+
defaultColor: false,
|
|
123
|
+
transformers: transformers(meta)
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
async function highlightCode(code, info = "") {
|
|
127
|
+
await prepareCodeHighlighter([parseCodeMeta(info).language]);
|
|
128
|
+
return highlightCodeSync(code, info);
|
|
129
|
+
}
|
|
130
|
+
export {
|
|
131
|
+
SHARED_CODE_GROUP_CONTRACT,
|
|
132
|
+
highlightCode,
|
|
133
|
+
highlightCodeSync,
|
|
134
|
+
normalizeCodeLanguage,
|
|
135
|
+
parseCodeMeta,
|
|
136
|
+
prepareCodeHighlighter
|
|
137
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Parse `::: footprints` containers into props for the shared Footprints UI. */
|
|
2
|
+
interface FootprintsPayload {
|
|
3
|
+
times: number[];
|
|
4
|
+
paragraphs: string[];
|
|
5
|
+
images: string[];
|
|
6
|
+
otherInfo: string;
|
|
7
|
+
}
|
|
8
|
+
/** `2025-01-22 23:47` / `2025-01-22` → [y,m,d,h?,min?,s?] */
|
|
9
|
+
declare function parseFootprintsDatetime(meta: string): number[];
|
|
10
|
+
/**
|
|
11
|
+
* Parse a full container source (`::: footprints …` … `:::`).
|
|
12
|
+
*/
|
|
13
|
+
declare function parseFootprintsSource(source: string): FootprintsPayload;
|
|
14
|
+
/** Rebuild canonical `::: footprints` source from structured fields. */
|
|
15
|
+
declare function rebuildFootprintsSource(payload: FootprintsPayload): string;
|
|
16
|
+
|
|
17
|
+
export { type FootprintsPayload, parseFootprintsDatetime, parseFootprintsSource, rebuildFootprintsSource };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// src/components/Footprints/parse.ts
|
|
2
|
+
function parseFootprintsDatetime(meta) {
|
|
3
|
+
const m = meta.trim().match(
|
|
4
|
+
/^(\d{4})-(\d{1,2})-(\d{1,2})(?:\s+(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/
|
|
5
|
+
);
|
|
6
|
+
if (!m) return [];
|
|
7
|
+
const parts = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
8
|
+
if (m[4] !== void 0) {
|
|
9
|
+
parts.push(Number(m[4]), Number(m[5]));
|
|
10
|
+
if (m[6] !== void 0) parts.push(Number(m[6]));
|
|
11
|
+
}
|
|
12
|
+
return parts;
|
|
13
|
+
}
|
|
14
|
+
function formatDatetime(times) {
|
|
15
|
+
if (times.length < 2) return "";
|
|
16
|
+
const [y, m, d, h, min, s] = times;
|
|
17
|
+
let out = `${String(y).padStart(4, "0")}-${String(m).padStart(2, "0")}`;
|
|
18
|
+
if (d !== void 0) out += `-${String(d).padStart(2, "0")}`;
|
|
19
|
+
if (h !== void 0 && min !== void 0) {
|
|
20
|
+
out += ` ${String(h).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
|
|
21
|
+
if (s !== void 0) out += `:${String(s).padStart(2, "0")}`;
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
function parseFootprintsSource(source) {
|
|
26
|
+
const normalized = source.replace(/\r\n?/g, "\n");
|
|
27
|
+
const lines = normalized.split("\n");
|
|
28
|
+
const open = lines[0]?.match(/^ {0,3}:{3,}\s*footprints(?:\s+(.*))?$/i);
|
|
29
|
+
const times = parseFootprintsDatetime((open?.[1] ?? "").trim());
|
|
30
|
+
let end = lines.length - 1;
|
|
31
|
+
while (end > 0 && !/^ {0,3}:{3,}\s*$/.test(lines[end])) end -= 1;
|
|
32
|
+
const bodyLines = lines.slice(1, end);
|
|
33
|
+
const paragraphs = [];
|
|
34
|
+
const images = [];
|
|
35
|
+
let otherInfo = "";
|
|
36
|
+
let buf = [];
|
|
37
|
+
let inOther = false;
|
|
38
|
+
const flush = () => {
|
|
39
|
+
const text = buf.join("\n").trim();
|
|
40
|
+
buf = [];
|
|
41
|
+
if (!text) return;
|
|
42
|
+
if (inOther) {
|
|
43
|
+
otherInfo = otherInfo ? `${otherInfo}
|
|
44
|
+
${text}` : text;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const imgOnly = text.match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)$/);
|
|
48
|
+
if (imgOnly) {
|
|
49
|
+
images.push(imgOnly[2]);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const parts = text.split("\n");
|
|
53
|
+
let allImg = true;
|
|
54
|
+
const blockImgs = [];
|
|
55
|
+
for (const line of parts) {
|
|
56
|
+
const m = line.trim().match(/^!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)$/);
|
|
57
|
+
if (m) blockImgs.push(m[2]);
|
|
58
|
+
else if (line.trim()) {
|
|
59
|
+
allImg = false;
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (allImg && blockImgs.length) {
|
|
64
|
+
images.push(...blockImgs);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
paragraphs.push(text);
|
|
68
|
+
};
|
|
69
|
+
for (const line of bodyLines) {
|
|
70
|
+
if (line.trim() === "---") {
|
|
71
|
+
flush();
|
|
72
|
+
inOther = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (line.trim() === "") {
|
|
76
|
+
flush();
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
buf.push(line);
|
|
80
|
+
}
|
|
81
|
+
flush();
|
|
82
|
+
return { times, paragraphs, images, otherInfo };
|
|
83
|
+
}
|
|
84
|
+
function rebuildFootprintsSource(payload) {
|
|
85
|
+
const meta = formatDatetime(payload.times);
|
|
86
|
+
const open = meta ? `::: footprints ${meta}` : "::: footprints";
|
|
87
|
+
const chunks = [open, ""];
|
|
88
|
+
for (const p of payload.paragraphs) {
|
|
89
|
+
chunks.push(p, "");
|
|
90
|
+
}
|
|
91
|
+
for (const src of payload.images) {
|
|
92
|
+
chunks.push(``, "");
|
|
93
|
+
}
|
|
94
|
+
if (payload.otherInfo.trim()) {
|
|
95
|
+
chunks.push("---", "", payload.otherInfo.trim(), "");
|
|
96
|
+
}
|
|
97
|
+
chunks.push(":::");
|
|
98
|
+
return `${chunks.join("\n")}
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
export {
|
|
102
|
+
parseFootprintsDatetime,
|
|
103
|
+
parseFootprintsSource,
|
|
104
|
+
rebuildFootprintsSource
|
|
105
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
interface MindmapFenceOptions {
|
|
2
|
+
title?: string;
|
|
3
|
+
initialExpandLevel?: number;
|
|
4
|
+
}
|
|
5
|
+
interface MindmapReference {
|
|
6
|
+
path: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Parse the canonical `mindmap [title] 2` fence metadata. */
|
|
10
|
+
declare function parseMindmapFence(openLine: string): MindmapFenceOptions | null;
|
|
11
|
+
/** @deprecated Mindmap fences no longer resolve `<<<` includes; kept for body-include parsers / tests. */
|
|
12
|
+
declare function parseMindmapReference(line: string): MindmapReference | null;
|
|
13
|
+
interface NormalizeMindmapOptions {
|
|
14
|
+
title?: string;
|
|
15
|
+
defaultTitle?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Ensure canonical mindmap Markdown has exactly one H1 root title. */
|
|
18
|
+
declare function normalizeMindmapMarkdown(source: string, options?: NormalizeMindmapOptions): string;
|
|
19
|
+
|
|
20
|
+
export { type MindmapFenceOptions, type MindmapReference, type NormalizeMindmapOptions, normalizeMindmapMarkdown, parseMindmapFence, parseMindmapReference };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// src/components/Mindmap/markdown.ts
|
|
2
|
+
function cleanHeadingText(value) {
|
|
3
|
+
return value.trim().replace(/\s+#+\s*$/, "").trim();
|
|
4
|
+
}
|
|
5
|
+
function parseMindmapFence(openLine) {
|
|
6
|
+
const fenceBody = openLine.trim().replace(/^`+\s*/, "");
|
|
7
|
+
const nameMatch = fenceBody.match(/^mindmap(?=\s|\[|$)/);
|
|
8
|
+
if (!nameMatch) return null;
|
|
9
|
+
let rest = fenceBody.slice(nameMatch[0].length).trim();
|
|
10
|
+
const options = {};
|
|
11
|
+
const titleMatch = rest.match(/\[([^\]]+)\]/);
|
|
12
|
+
if (titleMatch) {
|
|
13
|
+
options.title = cleanHeadingText(titleMatch[1]) || void 0;
|
|
14
|
+
rest = `${rest.slice(0, titleMatch.index)} ${rest.slice((titleMatch.index ?? 0) + titleMatch[0].length)}`.trim();
|
|
15
|
+
}
|
|
16
|
+
if (rest && !/^\d+$/.test(rest)) return null;
|
|
17
|
+
if (rest) {
|
|
18
|
+
options.initialExpandLevel = Math.max(1, Number(rest));
|
|
19
|
+
}
|
|
20
|
+
return options;
|
|
21
|
+
}
|
|
22
|
+
function parseMindmapReference(line) {
|
|
23
|
+
const match = line.trim().match(/^<<<\s+(.+?)\s*$/);
|
|
24
|
+
if (!match) return null;
|
|
25
|
+
let rest = match[1].trim();
|
|
26
|
+
let title;
|
|
27
|
+
const titleMatch = rest.match(/\s+\[([^\]]+)\]\s*$/);
|
|
28
|
+
if (titleMatch) {
|
|
29
|
+
title = cleanHeadingText(titleMatch[1]) || void 0;
|
|
30
|
+
rest = rest.slice(0, titleMatch.index).trim();
|
|
31
|
+
}
|
|
32
|
+
const path = rest.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, "$1$2").trim();
|
|
33
|
+
return path ? { path, title } : null;
|
|
34
|
+
}
|
|
35
|
+
function normalizeMindmapMarkdown(source, options = {}) {
|
|
36
|
+
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
37
|
+
let existingTitle = "";
|
|
38
|
+
let rootIndex = -1;
|
|
39
|
+
for (let index = 0; index < lines.length; index++) {
|
|
40
|
+
const match = lines[index].match(/^\s{0,3}#(?!#)\s+(.+?)\s*$/);
|
|
41
|
+
if (!match) continue;
|
|
42
|
+
existingTitle = cleanHeadingText(match[1]);
|
|
43
|
+
rootIndex = index;
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
const rootTitle = cleanHeadingText(options.title || existingTitle || options.defaultTitle || "root") || "root";
|
|
47
|
+
const body = lines.filter((_, index) => index !== rootIndex);
|
|
48
|
+
while (body[0]?.trim() === "") body.shift();
|
|
49
|
+
while (body[body.length - 1]?.trim() === "") body.pop();
|
|
50
|
+
return body.length > 0 ? `# ${rootTitle}
|
|
51
|
+
|
|
52
|
+
${body.join("\n")}
|
|
53
|
+
` : `# ${rootTitle}
|
|
54
|
+
`;
|
|
55
|
+
}
|
|
56
|
+
export {
|
|
57
|
+
normalizeMindmapMarkdown,
|
|
58
|
+
parseMindmapFence,
|
|
59
|
+
parseMindmapReference
|
|
60
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Shared image caption, width, and alignment helpers for SSG and Desk. */
|
|
2
|
+
type ImageAlign = 'left' | 'center' | 'right';
|
|
3
|
+
interface ImageMarkdown {
|
|
4
|
+
alt: string;
|
|
5
|
+
src: string;
|
|
6
|
+
title?: string;
|
|
7
|
+
/** Normalized CSS size, e.g. `50%` or `400px`. Empty means natural width. */
|
|
8
|
+
width?: string;
|
|
9
|
+
/** Omitted or `left` is the default and is not written to markdown. */
|
|
10
|
+
align?: ImageAlign;
|
|
11
|
+
}
|
|
12
|
+
interface ImageAttrs {
|
|
13
|
+
width: string;
|
|
14
|
+
align: ImageAlign;
|
|
15
|
+
rest: string;
|
|
16
|
+
}
|
|
17
|
+
declare function normalizeImageAlign(raw: string): ImageAlign;
|
|
18
|
+
declare function parseImageAttrs(text: string): ImageAttrs;
|
|
19
|
+
declare function parseImageWidthAttr(text: string): {
|
|
20
|
+
width: string;
|
|
21
|
+
rest: string;
|
|
22
|
+
};
|
|
23
|
+
declare function normalizeImageWidth(raw: string): string;
|
|
24
|
+
/** Logical CSS px for a pasted bitmap, folding device pixel ratio (4K/retina). */
|
|
25
|
+
declare function pasteDisplayWidthPx(naturalWidth: number, devicePixelRatio?: number): string;
|
|
26
|
+
declare function formatImageWidthAttr(width: string): string;
|
|
27
|
+
declare function formatImageAttrs(attrs: {
|
|
28
|
+
width?: string;
|
|
29
|
+
align?: ImageAlign | string;
|
|
30
|
+
}): string;
|
|
31
|
+
declare function serializeImageMarkdown(image: ImageMarkdown): string;
|
|
32
|
+
|
|
33
|
+
export { type ImageAlign, type ImageAttrs, type ImageMarkdown, formatImageAttrs, formatImageWidthAttr, normalizeImageAlign, normalizeImageWidth, parseImageAttrs, parseImageWidthAttr, pasteDisplayWidthPx, serializeImageMarkdown };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// src/markdown/image.ts
|
|
2
|
+
var ATTR_BLOCK = /^\s*\{([^}]*)\}\s*$/i;
|
|
3
|
+
function normalizeImageAlign(raw) {
|
|
4
|
+
const value = raw.trim().toLowerCase();
|
|
5
|
+
if (value === "center" || value === "middle") return "center";
|
|
6
|
+
if (value === "right") return "right";
|
|
7
|
+
return "left";
|
|
8
|
+
}
|
|
9
|
+
function parseImageAttrs(text) {
|
|
10
|
+
const match = text.match(ATTR_BLOCK);
|
|
11
|
+
if (!match) return { width: "", align: "left", rest: text };
|
|
12
|
+
const body = match[1].trim();
|
|
13
|
+
if (!body) return { width: "", align: "left", rest: "" };
|
|
14
|
+
if (!/(?:^|[\s,])(?:w|width|align|a)\s*=/i.test(` ${body}`)) {
|
|
15
|
+
return { width: "", align: "left", rest: text };
|
|
16
|
+
}
|
|
17
|
+
let width = "";
|
|
18
|
+
let align = "left";
|
|
19
|
+
for (const part of body.split(/[\s,]+/).filter(Boolean)) {
|
|
20
|
+
const eq = part.indexOf("=");
|
|
21
|
+
if (eq < 0) continue;
|
|
22
|
+
const key = part.slice(0, eq).trim().toLowerCase();
|
|
23
|
+
const value = part.slice(eq + 1).trim();
|
|
24
|
+
if (key === "w" || key === "width") width = normalizeImageWidth(value);
|
|
25
|
+
else if (key === "align" || key === "a") align = normalizeImageAlign(value);
|
|
26
|
+
}
|
|
27
|
+
return { width, align, rest: "" };
|
|
28
|
+
}
|
|
29
|
+
function parseImageWidthAttr(text) {
|
|
30
|
+
const parsed = parseImageAttrs(text);
|
|
31
|
+
return { width: parsed.width, rest: parsed.rest };
|
|
32
|
+
}
|
|
33
|
+
function normalizeImageWidth(raw) {
|
|
34
|
+
const value = raw.trim();
|
|
35
|
+
if (!value) return "";
|
|
36
|
+
const pixels = value.match(/^(\d+(?:\.\d+)?)px$/i);
|
|
37
|
+
if (pixels) {
|
|
38
|
+
const amount2 = Number(pixels[1]);
|
|
39
|
+
if (!Number.isFinite(amount2) || amount2 < 16) return "";
|
|
40
|
+
return `${Math.round(amount2)}px`;
|
|
41
|
+
}
|
|
42
|
+
const percent = value.match(/^(\d+(?:\.\d+)?)%?$/);
|
|
43
|
+
if (!percent) return "";
|
|
44
|
+
const amount = Number(percent[1]);
|
|
45
|
+
if (!Number.isFinite(amount) || amount <= 0 || amount > 100) return "";
|
|
46
|
+
const normalized = Number.isInteger(amount) ? String(amount) : String(Math.round(amount * 100) / 100);
|
|
47
|
+
return `${normalized}%`;
|
|
48
|
+
}
|
|
49
|
+
function pasteDisplayWidthPx(naturalWidth, devicePixelRatio = 1) {
|
|
50
|
+
if (!Number.isFinite(naturalWidth) || naturalWidth <= 0) return "";
|
|
51
|
+
const dpr = Number.isFinite(devicePixelRatio) && devicePixelRatio > 0 ? devicePixelRatio : 1;
|
|
52
|
+
return normalizeImageWidth(`${Math.round(naturalWidth / dpr)}px`);
|
|
53
|
+
}
|
|
54
|
+
function formatImageWidthAttr(width) {
|
|
55
|
+
return formatImageAttrs({ width, align: "left" });
|
|
56
|
+
}
|
|
57
|
+
function formatImageAttrs(attrs) {
|
|
58
|
+
const width = normalizeImageWidth(attrs.width ?? "");
|
|
59
|
+
const align = normalizeImageAlign(attrs.align ?? "");
|
|
60
|
+
const parts = [];
|
|
61
|
+
if (width) parts.push(`w=${width}`);
|
|
62
|
+
if (align !== "left") parts.push(`align=${align}`);
|
|
63
|
+
return parts.length > 0 ? `{${parts.join(" ")}}` : "";
|
|
64
|
+
}
|
|
65
|
+
function serializeImageMarkdown(image) {
|
|
66
|
+
const alt = image.alt.replaceAll("[", "").replaceAll("]", "");
|
|
67
|
+
const title = image.title?.trim() ? ` "${image.title.replaceAll('"', "")}"` : "";
|
|
68
|
+
const suffix = formatImageAttrs({
|
|
69
|
+
width: image.width ?? "",
|
|
70
|
+
align: image.align ?? "left"
|
|
71
|
+
});
|
|
72
|
+
return `${suffix ? ` ${suffix}` : ""}`;
|
|
73
|
+
}
|
|
74
|
+
export {
|
|
75
|
+
formatImageAttrs,
|
|
76
|
+
formatImageWidthAttr,
|
|
77
|
+
normalizeImageAlign,
|
|
78
|
+
normalizeImageWidth,
|
|
79
|
+
parseImageAttrs,
|
|
80
|
+
parseImageWidthAttr,
|
|
81
|
+
pasteDisplayWidthPx,
|
|
82
|
+
serializeImageMarkdown
|
|
83
|
+
};
|